Jerry's Log

Virtual DOM

contents

Virtual DOM은 흔히 React 성능의 "마법"이라고 불리지만, 실제로는 UI에 적용된 "더티 체킹(dirty checking)" 또는 "이중 버퍼링(double buffering)" 이라는 매우 논리적인 소프트웨어 엔지니어링 패턴입니다.

이것이 무엇인지, 왜 존재하는지, 그리고 구체적으로 어떤 알고리즘으로 작동하는지 알아보겠습니다.


1. 문제점: 실제 DOM은 "비쌉니다"

Virtual DOM을 이해하려면 먼저 왜 일반적인 브라우저 DOM(Document Object Model)을 그냥 사용하면 안 되는지 알아야 합니다.

DOM은 자바스크립트가 HTML과 상호작용할 수 있게 해주는 인터페이스입니다. 하지만 DOM은 현대 앱에서 볼 수 있는 동적이고 빈번한 업데이트(예: 검색창 타이핑, 실시간 채팅 피드 등)에 최적화되어 있지 않습니다.

Virtual DOM은 실제 DOM과의 상호작용을 최소화함으로써 이 문제를 해결합니다.


2. Virtual DOM이란 무엇인가?

Virtual DOM(VDOM)은 UI의 가상 표현을 메모리에 유지하는 프로그래밍 개념입니다.

기술적으로 말하면, 이것은 실제 DOM의 구조를 흉내 낸 경량 자바스크립트 객체(JavaScript Object)일 뿐입니다.

실제 DOM 노드:

Hello

(이것은 수백 개의 속성과 메서드를 가진 무거운 브라우저 객체입니다.)

Virtual DOM 노드 (자바스크립트 객체):

const vNode = {
  type: 'div',
  props: {
    id: 'container',
    className: 'active',
    children: [
      {
        type: 'h1',
        props: { children: 'Hello' }
      }
    ]
  }
};

(이것은 평범한 JS 객체입니다. 10만 개를 만들어도 매우 빠르고 메모리를 거의 차지하지 않습니다.)


3. 처리 과정: React가 업데이트하는 방식

React에서 setState를 사용하면 다음과 같은 과정이 일어납니다. 크게 렌더 단계(Render Phase)커밋 단계(Commit Phase) 로 나뉩니다.

1단계: 렌더 단계 (계산)

이 단계는 React가 무엇이 바뀌어야 하는지 알아내는 단계입니다. 순수 자바스크립트로 이루어지며 아직 브라우저 DOM을 건드리지 않습니다.

  1. 스냅샷 (Snapshot): React는 변경된 State를 기반으로 UI가 어떻게 보여야 하는지 나타내는 새로운 Virtual DOM 트리를 생성합니다.
  2. 비교 (Diffing / Reconciliation): React는 이 새로운 가상 트리이전 가상 트리(이전 렌더링 결과)와 비교합니다.
  3. 변경 사항 식별: 실제 DOM을 새로운 가상 트리와 일치시키기 위해 필요한 최소한의 변경 목록("diff")을 계산합니다.

2단계: 커밋 단계 (적용)

이제 React가 실제로 브라우저를 건드리는 단계입니다.

  1. 일괄 업데이트 (Batch Update): React는 계산된 최소한의 변경 목록을 가져와 실제 DOM에 한 번에(Batch) 적용합니다.
  2. 레이아웃: 브라우저는 상태가 바뀔 때마다 계산하는 것이 아니라, 딱 한 번만 리플로우/리페인트를 수행하면 됩니다.

4. 알고리즘: 재조정 ("Diffing" 로직)

거대한 객체 트리 두 개를 비교하는 것은 이론적으로 비용이 많이 듭니다. 일반적인 트리 비교 알고리즘의 복잡도는 O(n³) 입니다.

React는 휴리스틱(Heuristic) O(n) 알고리즘을 사용합니다. 이 알고리즘은 10억 번의 연산을 단 1,000번으로 줄이기 위해 두 가지 주요한 가정을 합니다.

가정 1: 타입이 다르면 다른 트리다

컴포넌트의 루트 요소 타입이 바뀌면, React는 이전 트리를 완전히 파괴하고 처음부터 새로 만듭니다. 이전 것을 고쳐 쓰려고 시간을 낭비하지 않습니다.

가정 2: 리스트의 Keys (키)

이것이 바로 React가 리스트에서 "key가 없다"고 경고하는 이유입니다.

Key가 없다면, 리스트 [A, B, C]맨 앞Z를 추가하여 [Z, A, B, C]가 될 때, React는 인덱스(순서)별로 비교합니다.

  1. 인덱스 0이 A에서 Z로 바뀜 (업데이트)
  2. 인덱스 1이 B에서 A로 바뀜 (업데이트)
  3. 인덱스 2가 C에서 B로 바뀜 (업데이트)
  4. 인덱스 3이 새로 생김 (C 추가)

결국 React는 리스트 전체 를 다시 그립니다.

Key가 있다면 (key="unique_id"):

React는 인덱스가 아닌 Key를 기준으로 아이템을 비교합니다.

  1. React: "Key가 'A'인 아이템은 그대로 있네, 위치만 옮겨."
  2. React: "Key가 'B'인 아이템도 그대로 있네."
  3. React: "Key가 'Z'인 아이템은 새로 생겼네."

결과: React는 단순히 Z를 삽입하고 나머지는 이동시킵니다.


5. React Fiber: 현대적인 엔진

구버전 React(Stack Reconciler)에서는 Virtual DOM 비교 작업이 동기적(Synchronous)이고 재귀적이었습니다. 한번 비교를 시작하면 트리가 끝날 때까지 멈출 수 없었습니다. 트리가 거대하면 브라우저가 버벅거렸습니다(프레임 드랍).

React Fiber (v16에서 도입) 는 Virtual DOM 엔진을 완전히 새로 짰습니다.


6. 요약: 전체 흐름

정리하자면, Virtual DOM 업데이트의 생명주기는 다음과 같습니다.

  1. 트리거 (Trigger): 상태 변경 발생 (setState).
  2. 렌더 (Render - Virtual): React가 컴포넌트 함수를 실행하여 새로운 UI 반환값을 얻음.
  3. 비교 (Diff): React가 새로운 반환값과 이전 값을 비교.
  4. 재조정 (Reconcile): "이 특정 <div>className만 바뀌었음"을 식별.
  5. 커밋 (Commit - Real): React가 실제 DOM에 접근하여 document.getElementById('...').className = 'new-class'를 실행.

이러한 추상화 덕분에 개발자는 업데이트 때마다 페이지 전체를 새로 그리는 것처럼 직관적으로 코드를 짤 수 있지만, React는 내부적으로 브라우저에 필요한 최소한의 변경만 적용하여 성능을 보장합니다.

references